GH-50515: [C++][Compute] Respect parent validity bitmap when casting nested structs with non-nullable fields - #50546
Conversation
…ct cast When casting a struct with a nullable->non-nullable field change, CastStruct::Exec checks GetNullCount() on the child array without considering the parent struct's validity bitmap. This rejects casts where the child's physical nulls are fully masked by parent-level nulls. Intersect the parent and child validity bitmaps before deciding whether unmasked nulls exist, using BinaryBitBlockCounter for the common case and explicit fallbacks for absent bitmaps.
|
|
You can use |
| } | ||
| position += block.length; | ||
| } | ||
| } |
There was a problem hiding this comment.
If we get here, I think we should make sure that the final casted child does not have a null bitmap, otherwise it might violate expectations for a nullable field (which are not well specified, unfortunately).
| // Child has nulls but no bitmap (e.g. NullArray, RunEndEncoded, Union). | ||
| // We must semantically check if any valid parent element corresponds to a null child. |
There was a problem hiding this comment.
The BinaryBitBlockCounter path below does not account for logical nulls, so I don't think this one should, either.
We can just hardcode an error for NullArray.
|
@aaron-seq @davlee1972 thanks for the issue and PR. This is a bit tricky as we have never formalized what the nullable flag means for non-trivial types, so I've started a discussion on the dev ML to make sure we don't go into the wrong direction here: |
|
I understand the complexity and for pure performance reasons I don't think you want to add Nulls in a non nullable array to begin with, but I'm forced to even out array lengths between parent records and child records. I added a function called fill_nulls_with_dummy() as a workaround to populate any non nullable array with zeros, empty string, 1970 dates, 0 duration, etc.. Maybe what we really need is a pyarrow.notnull() type, an option in pyarrow.compute.fill_null() to use a dummy fill value based on the values type and a pyarrow compute non_null_scalar() function that takes a type and returns back a dummy scalar. |
|
I actually like how assembling list arrays work with a pure non nullable array with an offsets array which has None for null positions without having to pollute the data array with None values. It would be a major change to support an offsets array for struct arrays.. [0,1,2,None,None,3]. For a struct array with only four non nullable array values but contains two additional null entries. |
…cast - Replace the manual BinaryBitBlockCounter loop with CountSetBits/ CountAndSetBits for the case where both parent and child have validity bitmaps. - Conservatively reject (rather than doing a per-element logical-null check) when the child has no validity buffer of its own, e.g. NullType, RunEndEncoded, or Union, since a bitmap-only check can't distinguish masked from unmasked nulls there anyway. - Strip the validity buffer from a non-nullable field's cast output so masked-but-physically-null child data doesn't leak through if that field is later extracted on its own. - Add a regression test covering the no-child-bitmap path, rename the test that didn't actually exercise it, and fix a couple of lint nits (an overlong line and a missing blank line before the next test).
|
Thanks for the review! Pushed a follow-up commit addressing all three points: CountAndSetBits: replaced the manual BinaryBitBlockCounter/NextAndNotWord loop with CountSetBits(parent) - CountAndSetBits(parent, child) for the case where both parent and child have validity bitmaps. Non-nullable field output: the cast result for a non-nullable field now has its validity buffer stripped before being attached to the parent. Previously the masked-but-physically-null bits from the source could survive into the output, which would surface as real nulls if that field were later extracted on its own (e.g. struct_field/flatten) thanks for catching that, it was the one actual correctness gap left. Also added a regression test for the no-bitmap-child path (the existing "AbsentChild" test didn't actually exercise it, so I renamed it to reflect what it covers and added a new one), and fixed a couple of lint nits (an overlong line, a missing blank line in the Python test). |
pitrou
left a comment
There was a problem hiding this comment.
Thanks for the update @aaron-seq ! Here are a couple more suggestions.
| unmasked_null_count = arrow::internal::CountSetBits( | ||
| parent_bitmap, in_array.offset, in_array.length) - | ||
| arrow::internal::CountAndSetBits( | ||
| parent_bitmap, in_array.offset, child_bitmap, | ||
| in_values->offset, in_array.length); |
There was a problem hiding this comment.
Can we instead add a function CountAndNotSetBits in arrow/util/bitmap_ops.h and use it directly here?
| // Child reports nulls but has no validity buffer of its own (e.g. | ||
| // NullArray, RunEndEncoded, Union). There is no cheap, bitmap-only way |
There was a problem hiding this comment.
This will only apply to NullArray. RunEndEncoded and Union should return 0 for GetNullCount (which should not be confused with ComputeLogicalNullCount).
| auto src = ArrayFromJSON(outer_type_src, R"([ | ||
| {"inner": {"a": 1}}, | ||
| null | ||
| ])"); |
There was a problem hiding this comment.
This does not guarantee that the inner field will have nulls at all, it's just an implementation detail of ArrayFromJSON. Can you instead use StructArray::Make to construct the outer array from its inner child array?
| TEST(Cast, StructNestedNullabilityNoChildBitmapConservativelyRejected) { | ||
| // NullType (and other child types without a validity buffer of their own, | ||
| // e.g. RunEndEncoded/Union) have no bitmap to distinguish masked from | ||
| // unmasked nulls, so any reported null is conservatively rejected -- even |
There was a problem hiding this comment.
I don't think REE or Union would be rejected, can you fix the comment?
| ])"); | ||
| EXPECT_RAISES_WITH_MESSAGE_THAT( | ||
| Invalid, ::testing::HasSubstr("has nulls. Can't cast to non-nullable field"), | ||
| Cast(src, CastOptions::Safe(outer_type_dest))); |
There was a problem hiding this comment.
Can you also check with a zero-length input?
| EXPECT_RAISES_WITH_MESSAGE_THAT( | ||
| Invalid, ::testing::HasSubstr("has nulls. Can't cast to non-nullable field"), | ||
| Cast(src->Slice(1, 1), CastOptions::Safe(outer_type_dest))); | ||
| } |
There was a problem hiding this comment.
Can you also check with a zero-length input? (not sure whether it should succeed or fail)
- Add arrow::internal::CountAndNotSetBits to bitmap_ops, replacing the CountSetBits/CountAndSetBits subtraction in the struct cast kernel with a single "parent valid AND child null" count, plus a direct unit test. - Drop the "no cheap way to tell" reasoning for bitmap-less children: GetNullCount() only counts physical nulls, so union and run-end encoded children report 0 and never reach the check. NullType is the only type that does, and it is always rejected. Comments updated accordingly. - Build the nested-nullability test inputs with StructArray::Make and explicit validity bitmaps so where "a" physically holds nulls is pinned down by the test instead of ArrayFromJSON's layout choices. - Cover zero-length input on both the int32 and NullType paths: an empty slice reports no nulls, so it succeeds even at the offset of a null that the full array is rejected for. - Strip trailing whitespace in test_compute.py (autopep8 lint failure).
|
Thanks @pitrou, all five points addressed in da10b42.
Bitmap-less children — you're right, I had this backwards. Test construction — the nested-nullability inputs are now built with NullType test comment — rewritten; it no longer claims REE/union are rejected, and notes they report no physical nulls so they're unaffected. Zero-length input — added on both paths. It succeeds: an empty slice reports no nulls, so the check is skipped. Worth noting the NullType case is the interesting one — Also stripped trailing whitespace in One thing I left alone pending the ML thread: the check runs per cast level, so if the outer element is null but the inner struct is physically valid there with a null leaf, the recursive inner cast still rejects it — it has no view of the outer bitmap. I didn't add a test asserting that either way, since it seems to be exactly what's under discussion. |
Rationale for this change
When casting a nested struct whose inner field changes from nullable to
non-nullable,
CastStruct::Execcurrently callsin_values->GetNullCount() > 0without considering the parent struct'svalidity bitmap. This causes a spurious
ArrowInvaliderror when thechild's physical nulls are fully masked by null parent entries.
Reported in #50515.
What changes are included in this PR?
In
scalar_cast_nested.cc, replace the unconditional rejection with athree-way check:
BinaryBitBlockCounter::NextAndNotWord()to detect parent-valid-AND-child-null positions without heap allocation.
Are these changes tested?
Five new C++ test cases in
scalar_cast_test.cc:StructNestedNullabilityAbsentParent— true violation, must rejectStructNestedNullabilityMasked— masked null, must succeedStructNestedNullabilitySliced— offset correctness for both outcomesStructNestedNullabilityAbsentChild— no nulls, must succeedStructNestedNullabilityDeep— 3-level nesting with masked nullOne new Python test in
test_compute.py:test_cast_struct_nested_nullability— end-to-end PyArrow validationcovering success, rejection, and sliced array cases
Are there any user-facing changes?
Struct casts that were previously rejected with
ArrowInvalid: field '...' has nullswill now succeed when the nullsare masked by parent-level nulls. This is a correctness fix, not a
behavior change — the previous behavior was incorrect per the Arrow
columnar format specification.